Interprétation de modèles avec ELI5 , LIME et SHAP
L'idée de ce tutoriel est de fournir des outils et des techniques permettant d’interpréter des modèles d'apprentissage. Ce tutoriel est quasi identique à celui disponible sur ce lien : https://towardsdatascience.com/explainable-artificial-intelligence-part-3-hands-on-machine-learning-model-interpretation-e8ebe5afc608. A la différence du tutoriel original celui-ci contient :
Utilisation de ces 3 packages :
5 concepts sont abordés dans ce tutoriels :
- Importance des variables
- Partielle dépendance
- Explication d'un modèle de prédiction avec une interprétation locale
- Explication d'un modèle de prédiction avec SHAP
- Dépendence et intéractions entre variables avec SHAP
Jeu de données : Adult Data Set
| Attribute Name | Type | Description |
|---|---|---|
| Age | Continuous | Represents age of the person |
| Workclass | Categorical | Represents the nature of working class\category (Private, Self-emp-not-inc, Self-emp-inc, Federal-gov, Local-gov, State-gov, Without-pay, Never-worked) |
| Education-Num | Categorical | Numeric representation of educational qualification. Ranges from 1-16. (Bachelors, Some-college, 11th, HS-grad, Prof-school, Assoc-acdm, Assoc-voc, 9th, 7th-8th, 12th, Masters, 1st-4th, 10th, Doctorate, 5th-6th, Preschool) |
| Marital Status | Categorical | Represents the marital status of the person (Married-civ-spouse, Divorced, Never-married, Separated, Widowed, Married-spouse-absent, Married-AF-spouse) |
| Occupation | Categorical | Represents the type of profession\job of the person (Tech-support, Craft-repair, Other-service, Sales, Exec-managerial, Prof-specialty, Handlers-cleaners, Machine-op-inspct, Adm-clerical, Farming-fishing, Transport-moving, Priv-house-serv, Protective-serv, Armed-Forces) |
| Relationship | Categorical | Represents the relationship status of the person (Wife, Own-child, Husband, Not-in-family, Other-relative, Unmarried) |
| Race | Categorical | Represents the race of the person (White, Asian-Pac-Islander, Amer-Indian-Eskimo, Other, Black) |
| Sex | Categorical | Represents the gender of the person (Female, Male) |
| Capital Gain | Continuous | The total capital gain for the person |
| Capital Loss | Continuous | The total capital loss for the person |
| Hours per week | Continuous | Total hours spent working per week |
| Country | Categorical | The country where the person is residing |
| Income Label (labels) | Categorical (class label) | The class label column is the one we want to predict (False: Income <= \$50K & True: Income > \$50K) |
Jeu de données de 32 561 individus décrit par 12 variables dont la variable cible "Income Label"
Pour l'installation des différents package ils peuvent se faire avec la commande conda (si vous possédez anaconda ou pip).
#Chargement des librairies et des dépendances
import pandas as pd
import numpy as np
from collections import Counter
import shap
import eli5
from sklearn import metrics
import matplotlib.pyplot as plt
from sklearn.preprocessing import LabelEncoder
from sklearn.base import clone
from sklearn.preprocessing import label_binarize
from scipy import interp
from sklearn.metrics import roc_curve, auc
import warnings
warnings.filterwarnings('ignore')
plt.style.use('fivethirtyeight')
%matplotlib inline
shap.initjs()
#Fonction qui vont nous servir pour résumer les performances de notre modèle de prédiction
def get_metrics(true_labels, predicted_labels):
print('Accuracy:', np.round(metrics.accuracy_score(true_labels, predicted_labels),4))
print('Precision:', np.round(metrics.precision_score(true_labels, predicted_labels,average='weighted'),4))
print('Recall:', np.round(metrics.recall_score(true_labels, predicted_labels,average='weighted'),4))
print('F1 Score:', np.round(metrics.f1_score(true_labels, predicted_labels,average='weighted'),4))
def display_confusion_matrix(true_labels, predicted_labels, classes=[1,0]):
total_classes = len(classes)
level_labels = [total_classes*[0], list(range(total_classes))]
cm = metrics.confusion_matrix(y_true=true_labels, y_pred=predicted_labels, labels=classes)
cm_frame = pd.DataFrame(data=cm, columns=pd.MultiIndex(levels=[['Predicted:'], classes],labels=level_labels),index=pd.MultiIndex(levels=[['Actual:'], classes],labels=level_labels))
print(cm_frame)
def display_classification_report(true_labels, predicted_labels, classes=[1,0]):
report = metrics.classification_report(y_true=true_labels,y_pred=predicted_labels,labels=classes)
print(report)
def display_model_performance_metrics(true_labels, predicted_labels, classes=[1,0]):
print('Model Performance metrics:')
print('-'*30)
get_metrics(true_labels=true_labels, predicted_labels=predicted_labels)
print('\nModel Classification report:')
print('-'*30)
display_classification_report(true_labels=true_labels, predicted_labels=predicted_labels,
classes=classes)
print('\nPrediction Confusion Matrix:')
print('-'*30)
display_confusion_matrix(true_labels=true_labels, predicted_labels=predicted_labels,
classes=classes)
#Ce dataset fourni par shap est déjà nettoyé
data,labels = shap.datasets.adult(display=True)
data.head()
data.describe()
print("Data Shape : ", data.shape,"\n","Labels Shape :", labels.shape,'\n\n')
print("---------------------Distributions ---------------","\n")
print("Salaire < 50k : ",Counter(labels)[0],
"\n",
"Salaire > 50k :",Counter(labels)[1])
L'idée ici est d'encoder les différentes catégories des variables catégorielles en numérique.
data.info()
#Workclass, Marital Status, Occupation, Relationship, Race, Sex et Country
cat_cols = data.select_dtypes(['category']).columns
data[cat_cols] = data[cat_cols].apply(lambda x: x.cat.codes)
data.head()
Jeu de données encodé
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(data, labels, test_size=0.3, random_state=42)
X_train.shape,X_test.shape,y_train.shape,y_test.shape
Jeu de données avec libellé (cela va nous servir plus tard pour l'interprétation)
data_disp, labels_disp = shap.datasets.adult(display=True)
X_train_disp, X_test_disp, y_train_disp, y_test_disp = train_test_split(data_disp, labels_disp, test_size=0.3, random_state=42)
print(X_train_disp.shape, X_test_disp.shape)
X_train_disp.head(3)
%%time
import xgboost as xgb
xgc = xgb.XGBClassifier(n_estimators=500, max_depth=5, base_score=0.5,
objective='binary:logistic', random_state=42)
xgc.fit(X_train, y_train)
#Prédictions
predictions = xgc.predict(X_test)
class_labels = list(set(labels))
display_model_performance_metrics(true_labels=y_test,
predicted_labels=predictions,
classes=class_labels)
fig = plt.figure(figsize = (20, 15))
title = fig.suptitle("Default features importances from XGBoost", fontsize=14)
#Nombre de fois qu'une variable apparaît dans un arbre "Weight"
ax1 = fig.add_subplot(2,2, 1)
xgb.plot_importance(xgc, importance_type='weight', ax=ax1)
t=ax1.set_title("Feature Importance - Feature Weight")
#Basé sur le gain moyen des divisions utilisant la variable "Gain"
ax2 = fig.add_subplot(2,2, 2)
xgb.plot_importance(xgc, importance_type='gain', ax=ax2)
t=ax2.set_title("Feature Importance - Split Mean Gain")
#Basé le nombre moyen d'échantillons affectés par la scissions utilisant la variable "Coverage"
ax3 = fig.add_subplot(2,2, 3)
xgb.plot_importance(xgc, importance_type='cover', ax=ax3)
t=ax3.set_title("Feature Importance - Sample Coverage")
ELI5 est un package Python qui aide à déboguer les classifieurs et à expliquer leurs prédictions de manière intuitive et facile à comprendre. C’est le plus simple des trois packages. Cependant, il s’appuie principalement que sur les modèles de type arbre et linéaire. Il permet de faire une interprétation globale prédictions et local (individuelle).
#Feature Importance avec ELI5
#Rien ne change du feature importance classique. La méthode "gain" est sélectionné par défaut
eli5.show_weights(xgc)
Autre moyen d'interpréter les prédictions du modèle, consiste à les examiner de manière individuelle. En règle générale, ELI5 effectue cette opération en affichant des pondérations pour chaque variable, illustrant son influence potentielle sur la décision finale des prédictions de tous les arbres.
#Cas ou la personne est prédit ayant un salaire inférieur à 50k et a réellement un salaire inf à 50k
#y : correspond à la prédiction de l'individu
#probability : proba prédite d'être inf à 50k
#score : somme des contributions
ind_choisi = 0
print('Actual Label:', y_test[ind_choisi])
print('Predicted Label:', predictions[ind_choisi])
eli5.show_prediction(xgc, X_test.iloc[ind_choisi],
show_feature_values=True)
On s'aperçoit que les variables qui ont le plus influencé l'individu 0 à être prédit comme ayant un salaire inf. à 50k sont :
De plus Eli5 fournit le couple (variable - valeurs) ce qui peut être intéressant d'analyser. Mais nous verrons que ce couple a plus de sens pour les autres packages tels que LIME et SHAP.
#Cas ou la personne est prédit ayant un salaire supérieur à 50k et a réllement un salaire sup. à 50k
ind_choisi =2
print('Actual Label:', y_test[ind_choisi])
print('Predicted Label:', predictions[ind_choisi])
eli5.show_prediction(xgc, X_test.iloc[ind_choisi],
feature_names=list(data.columns),
show_feature_values=True)
On s'aperçoit que les variables qui ont le plus influencé l'individu 2 à être prédit comme ayant un salaire sup. à 50k sont :
Skater est un package qui permet l’interprétation de plusieurs modèles (linéaire, non linéaire). Plus élaboré que ELI5 sur certains points comme l'interprétation local de prédiction (influence des variables sur la prédiction d'un individu) et l'interprétation globale.
De base skater est un outil dérivé du package LIME cependant le projet ayant pris de l'ampleur il est devenu à lui-même un outil permettant d'interpréter des modèles
from skater.core.explanations import Interpretation
from skater.model import InMemoryModel
#Création d'un objet interprétation. Tous process utilisant skater doit passer par la création de cet objet
interpreter = Interpretation(training_data=X_test, feature_names=list(data.columns))
#InMemoryModel est la méthode utilisée pour l'interprétation d'un modèle en local
#DeployedModel est la méthode utilisée pour l'interprétation d'un modèle accessible via une API (voir doc)
interpreter_model = InMemoryModel(xgc.predict_proba, examples=X_train, target_names=['$50K or less', 'More than $50K'])
Skater a sa façon de calculer les features importances
plots = interpreter.feature_importance.plot_feature_importance(interpreter_model, ascending=False)
Le graphe de dépendance partielle (PDP ou PD) montre l'effet marginal d'une variable sur les prédictions. C'est à dire qu'il nous montre si la relation entre la variable cible et la variable choisie est linéaire, monotone ou plus complexe.
Skater permet de visualiser les PDP en une ou deux dimensions. Les variables choisies par la suite pour la visualisation des différents PDP sont celles que nos interpréteurs ont jugés être discriminante dans les prédictions de nos classes.
#Partielle dépendance de l'âge et des prédictions
# !! Pb d'affichage !! en ordonnée nous retrouvons les probabilités d'avoir un salaire sup. à 50k
age_pdp = interpreter.partial_dependence.plot_partial_dependence(['Age'], interpreter_model, grid_resolution=50,
grid_range=(0,1),with_variance=True, figsize = (6, 4))
new_y_lim = age_pdp[0][1].set_ylim(0, 1)
min_x, max_x = age_pdp[0][1].get_xlim()
new_x_lim = age_pdp[0][1].set_xticks(np.arange(20, 90, 10))
Le graphe nous montre que les personnes se trouvant entre 30 et 50 ans ont plus de chance de gagner 50k contrairement aux plus jeunes ou plus âgées.
education_pdp = interpreter.partial_dependence.plot_partial_dependence(['Education-Num'], interpreter_model, grid_resolution=50,
grid_range=(0,1),
with_variance=True, figsize = (6, 4))
y_lim = education_pdp[0][1].set_ylim(0, 1)
Sans surprise Le graphe nous montre que les personnes ayant fait plus d'études ont plus de chance de gagner 50k.
capital_gain_pdp = interpreter.partial_dependence.plot_partial_dependence(['Capital Gain'], interpreter_model, grid_resolution=50,
grid_range=(0,1),
with_variance=True, figsize = (18, 10))
yl = capital_gain_pdp[0][1].set_ylim(0, 1)
s, e = capital_gain_pdp[0][1].get_xlim()
xl = capital_gain_pdp[0][1].set_xticks(np.arange(-1000, e, 5000))
Sans surprise plus le capital gain est élevé plus on a de chance de gagner plus de 50k
#Construction d'une table de référence
pd.concat([data_disp[['Relationship']], data[['Relationship']]],axis=1).drop_duplicates()
relationship_pdp = interpreter.partial_dependence.plot_partial_dependence(['Relationship'], interpreter_model, grid_resolution=50,
grid_range=(0,1),
with_variance=True, figsize = (6, 4))
yl = relationship_pdp[0][1].set_ylim(0, 1)
Les couples (husband ou Wife) ont plus de chance d'avoir un salaire sup. à 50k
pd.concat([data_disp[['Occupation']], data[['Occupation']]],axis=1).drop_duplicates()
occupation_pdp = interpreter.partial_dependence.plot_partial_dependence(['Occupation'], interpreter_model, grid_resolution=50,
grid_range=(0,1),
with_variance=True, figsize = (6, 4))
yl = occupation_pdp[0][1].set_ylim(0, 1)
xl = occupation_pdp[0][1].set_xticks(np.arange(0,14,1))
Exec. Managerial, Prof speciality, Protective-serv, Sales et Tech support ont plus de chance d'avoir un salaire sup. à 50k
Cela permet de montrer les interactions entre deux variables et leurs effets sur la variable cible. Les variables choisies sont «Age» et «Education-Num».
age_edu_pdp = interpreter.partial_dependence.plot_partial_dependence([('Age', 'Education-Num')],
interpreter_model, grid_range=(0,1),
figsize=(15, 10),
grid_resolution=100)
On remarque que les personnes ayant un niveau d'étude élevé entre 30 et 50 ans ont plus de chance d'avoir un salaire sup à 50k
edu_capital_gain_pdp = interpreter.partial_dependence.plot_partial_dependence([('Education-Num','Capital Gain')],
interpreter_model, grid_range=(0,1),
figsize=(15, 10),
grid_resolution=100)
Avoir un haut niveau d'étude et un capital élevé augmente les chances d'avoir un salaire sup. à 50k
LIME (Local Interpretable Model-Agnostic Explanations) se focalise sur les individus afin d'expliquer leurs prédictions (interprétation local). LIME se base sur la méthode de perturbation des distributions de variables afin de voir l'influence de celles-ci dans les prédictions. LIME peut s'appliquer à de nombreux type de problématique et de données (numérique, catégorielle, mixte, image, textuelle).
#On relance xgboost avec numpy
xgc_np = xgb.XGBClassifier(n_estimators=500, max_depth=5, base_score=0.5,
objective='binary:logistic', random_state=42)
#Valeurs
xgc_np.fit(X_train.values, y_train)
xgc_np.predict_proba(X_test.values)
from skater.core.local_interpretation.lime.lime_tabular import LimeTabularExplainer
exp = LimeTabularExplainer(X_test.values, feature_names=list(data.columns),
discretize_continuous=True,
class_names=['$50K or less', 'More than $50K'])
#ici l'individu prédit a un salaire inf à 50k
#Notre modèle a prédit que son salaire est inf à 50k
ind_choisi = 0
print('Actual Label:', y_test[ind_choisi])
print('Predicted Label:', predictions[ind_choisi])
exp.explain_instance(X_test.iloc[ind_choisi].values, xgc_np.predict_proba).show_in_notebook()
Les variables qui influent le plus cette prédiction sont capital gain, l'âge et hous per week. Ici le couple (variable - valeurs) prend un sens.
Le fait d'avoir un capital inférieur ou égale à 0 influe la prédiction vers la classe salaire inf. à 50k.
#Prenons le cas réel d'une personne ayant un salaire sup. à 50k
ind_choisi = 2
print('Actual Label:', y_test[ind_choisi])
print('Predicted Label:', predictions[ind_choisi])
exp.explain_instance(X_test.iloc[ind_choisi].values, xgc_np.predict_proba).show_in_notebook()
Dans ce cas on peut voir que notre modèle n'est pas sûr à 100% qu'il s'agit d'une personne ayant un salaire sup. à 50k du fait que son capital est à 0. Cependant il prend en compte les 6 autres couples (variable-valeurs) qui pousse à croire que cette personne à un salaire sup à 50k.
C'est une approche unifiée pour expliquer les résultats de tout modèle d’apprentissage automatique. Approche globale et locale
from skater.core.explanations import Interpretation
#Création de l'objet explainer
explainer = shap.TreeExplainer(xgc)
#Calcul des shap values (expliqué sur le lien starter)
shap_values = explainer.shap_values(X_test)
print('Expected Value:', explainer.expected_value)
pd.DataFrame(shap_values).head(5)
SHAP nous donne la possibilité de voir sur un axe les variables qui ont le plus influencé notre prédiction de manière locale. Ici il s'agit d'une personne qui a un salaire inf. à 50k et que nous avons bien prédit. En "bleu" sont les variables ayant poussé la prédiction vers le bas (inf à 50k.) et en rouge l'inverse.
shap.force_plot(explainer.expected_value, shap_values[0,:], X_test_disp.iloc[0,:])
Cas inverse avec une personne ayant un salaire sup. à 50k
shap.force_plot(explainer.expected_value, shap_values[2,:], X_test_disp.iloc[2,:])
L'avantage de SHAP et ce qui est plutôt sympas c'est sa visualisation pour l'interprétation globale. Il est possible de regarder l'influence de chacune des variables sur un échantillon ou la totalité des individus du jeu de test.
shap.force_plot(explainer.expected_value,
shap_values[:1000,:], X_test_disp.iloc[:1000,:])
De nombreuses hypothèses peuvent être tiré de ce graphe et il y a possibilité de filtrer en fonction des différentes variables.
Ce graphe a pour objectif de résumer l'impact des variables. Les variables sont rangées par ordre de décroissant d'influence (se base sur la magnitude des SHAP value voir doc). La couleur de chaque point représente la valeur de la variable.
shap.summary_plot(shap_values,X_test)
Lecture : "Age" et "Marital status" ont un impact plus important dans les prédictions du modèle que le "capital gain". Cependant la variable "capital gain" à plus d'impact sur les prédictions que les variables "Age" et "Marital Status" lorsque ses valeurs sont élevées. En d’autres mots la variable "capital gain" affecte quelques prédictions lorsque le montant est important tandis que "Age" et "marital status" affecte toutes les prédictions par de petites valeurs.
Principe similaire au dépendance partielle de skater mais le calcul est différent. SHAP se base sur ces SHAP values.
shap.dependence_plot(ind='Age', interaction_index='Age',
shap_values=shap_values,
features=X_test,
display_features=X_test_disp)
Comme nous avons pu l'observer les personnes se trouvant au milieu ont une shap value plus élevée ce qui pousse le modèle à prédire que ces personnes sont plus probables à avoir un salaire sup. à 50k.
shap.dependence_plot(ind='Education-Num', interaction_index='Education-Num',
shap_values=shap_values,
features=X_test,
display_features=X_test_disp)
Plus le niveau d'étude est élevé plus on a de chance d'avoir un salaire sup à 50k.
shap.dependence_plot(ind='Relationship', interaction_index='Relationship',
shap_values=shap_values,
features=X_test,
display_features=X_test_disp)
shap.dependence_plot(ind='Capital Gain', interaction_index='Capital Gain',
shap_values=shap_values,
features=X_test,
display_features=X_test_disp)
shap.dependence_plot(ind='Age', interaction_index='Capital Gain',
shap_values=shap_values, features=X_test,
display_features=X_test_disp)
Avoir un capital gain élevé entre 30 et 50 ans pousse a avoir un salaire sup à 50k.
Les 3 packages ont leurs défauts et qualités. Le plus utilisé parmi les 3 est SHAP par ces bases solides de calculs et la participation de nombreux chercheurs à l'élaboration et l'amélioration du package. Cependant LIME ainsi que ELI5 peuvent très bien être utilisé pour expliquer au métier les prédictions de nos modèles plus au moins complexe.